import type { Metadata } from "next"; import Link from "next/link"; import { notFound } from "next/navigation"; import { apiSafe } from "@/lib/api"; import { PartyBadge, PartyDot, PartyLogo } from "@/components/party"; import { Badge, Card, KV, LiveBadge, ProbabilityBar, ProjectionBadge, SectionHeader, SourceBadge } from "@/components/ui"; import { RidingMap } from "@/components/riding-map"; import { RidingLive } from "@/components/riding-live"; import { dateFr, dateTimeFr, delta, int, pct, prob, ratingLabel } from "@/lib/format"; import { partyVar, partyLabel, partyName } from "@/lib/parties"; export const revalidate = 60; interface Detail { code: number; name: string; slug: string; region: string | null; regionName: string | null; electors: number | null; areaKm2: number | null; isNew: boolean; changed: boolean; centroid: [number, number]; incumbent: { party: string | null; name: string | null; running: boolean | null }; baseline2022: { party: string; share: number; votes: number; method: string; coverage: number | null; source: string }[]; candidates: { id: number; name: string; party: string | null; partyLabel: string | null; independent: boolean; incumbent: boolean; updatedAt: string | null }[]; candidatesSource: { name: string; url: string }; history: Record; historyNote: string; forecast?: { runId: number; completedAt: string | null; modelVersion: string; favorite: string; p: number; runnerUp: string | null; margin: number; rating: string; volatility: number; swing: number; factors: { factor: string; label: string; detail: string; direction: string }[]; parties: { party: string; voteMean: number; vote80: [number, number]; pWin: number }[] }; live: { status: string; updatedAt: string | null; bureaux: [number, number]; votesValid: number; turnout: number | null; leader: string | null; marginVotes: number | null; marginPct: number | null; candidates: { name: string; party_id: string; votes: number; share: number }[] | null; final: boolean } | null; nearby?: { code: number; name: string; slug: string }[]; demographics?: { population2021: number; density: number | null; medianAge: number | null; pct65Plus: number | null; pctFrenchMt: number | null; pctEnglishMt: number | null; pctOtherMt: number | null; pctPlopFrench: number | null; pctImmigrants: number | null; pctVisibleMinority: number | null; medianHouseholdIncome: number | null; pctOwner: number | null; pctBachelorPlus: number | null; unemploymentRate: number | null; adaCount: number; source: string } | null; pollRegion?: string; } export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise { const { slug } = await params; const d = await apiSafe(`/api/ridings/${slug}`); if (!d) return { title: "Circonscription" }; const fav = d.forecast ? `${partyLabel(d.forecast.favorite)} favori (${prob(d.forecast.p)})` : ""; return { title: `${d.name} — Prévision élection Québec 2026`, description: `${d.name} (${d.regionName}) : ${fav}. Candidat·es, résultat 2022 transposé, probabilité de victoire par parti et facteurs du modèle QC26.`, alternates: { canonical: `/circonscription/${d.slug}` } }; } export default async function Page({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; const d = await apiSafe(`/api/ridings/${slug}`); if (!d) notFound(); const f = d.forecast; const years = Object.keys(d.history).sort().reverse(); return (
← Circonscriptions{d.regionName}{d.isNew && Nouvelle ou renommée en 2026}{!d.isNew && d.changed && Limites modifiées}{d.live && d.live.status !== "NOT_STARTED" && }

{d.name}

{int(d.electors)} électeurs inscrits{d.areaKm2 ? ` · ${int(d.areaKm2)} km²` : ""} · sortant·e : {d.incumbent.name ? `${d.incumbent.name} (${partyLabel(d.incumbent.party)})${d.incumbent.running === false ? ", ne se représente pas" : d.incumbent.running ? ", candidat·e" : ""}` : "—"}
{f && (
Projection QC26 · run #{f.runId} · {dateTimeFr(f.completedAt)}
{partyName(f.favorite)} favori
{prob(f.p)}
probabilité de victoire · {ratingLabel(f.rating)}
Marge projetée
{f.margin.toFixed(1).replace(".", ",")} pt
vs {partyLabel(f.runnerUp)}
Swing vs 2022
{delta(f.swing, 1, " pt")}
{partyLabel(f.favorite)}
{f.parties.filter((p) => p.party !== "aut" || p.voteMean > 2).map((p) => (
{partyLabel(p.party)}
{p.voteMean.toFixed(1).replace(".", ",")} % ({p.vote80[0].toFixed(0)}–{p.vote80[1].toFixed(0)}) {prob(p.pWin)}
))}
Vote estimé (moyenne, intervalle 80 %) et probabilité de victoire.
)}
{d.nearby &&
Circonscriptions voisines
{d.nearby.map((n) => {n.name})}
}
{d.live && d.live.status !== "NOT_STARTED" && } {f && (
{f.factors.map((x, i) => (
{x.direction}
{x.label}
{x.detail}
))}
)}
} /> {d.candidates.length === 0 ?
Aucune candidature enregistrée pour l'instant.
: ( {d.candidates.sort((a, b) => (f?.parties.find((p) => p.party === b.party)?.pWin ?? 0) - (f?.parties.find((p) => p.party === a.party)?.pWin ?? 0)).map((c) => ())}
Candidat·eParti
{c.name}{c.party ? {partyLabel(c.party)} : {c.independent ? "Indépendant·e" : c.partyLabel}}{c.incumbent ? "Sortant·e" : ""}
)}
} />
{d.baseline2022.filter((b) => b.party !== "aut" || b.share > 1).map((b) => (
{partyLabel(b.party)}
{pct(b.share)}
))}
{d.baseline2022[0]?.coverage !== null && d.baseline2022[0]?.coverage !== undefined ? `${pct(d.baseline2022[0].coverage * 100, 0)} des votes proviennent de la circonscription 2022 principale.` : ""} Hypothèse : les votes par anticipation se répartissent comme ceux du jour du vote.
{d.demographics && (
} />
{([ ["Population 2021", int(d.demographics.population2021), ""], ["Langue maternelle française", d.demographics.pctFrenchMt, " %"], ["Langue maternelle anglaise", d.demographics.pctEnglishMt, " %"], ["Autres langues maternelles", d.demographics.pctOtherMt, " %"], ["Immigrant·es", d.demographics.pctImmigrants, " %"], ["Minorités visibles", d.demographics.pctVisibleMinority, " %"], ["65 ans et plus", d.demographics.pct65Plus, " %"], ["Âge médian", d.demographics.medianAge, " ans"], ["Revenu médian des ménages", d.demographics.medianHouseholdIncome ? int(d.demographics.medianHouseholdIncome) : null, " $"], ["Propriétaires", d.demographics.pctOwner, " %"], ["Baccalauréat ou plus (25-64)", d.demographics.pctBachelorPlus, " %"], ["Taux de chômage", d.demographics.unemploymentRate, " %"], ["Densité", d.demographics.density ? int(d.demographics.density) : null, " hab./km²"], ["Regroupement sondeurs", d.pollRegion === "montreal_rmr" ? "Grand Montréal" : d.pollRegion === "quebec_rmr" ? "Région de Québec" : "Reste du Québec", ""], ] as [string, string | number | null, string][]).map(([label, value, suffix]) => (
{label}
{value === null || value === undefined ? "—" : typeof value === "number" ? value.toLocaleString("fr-CA", { maximumFractionDigits: 1 }) : value}{value === null ? "" : suffix}
))}
)} {years.length > 0 && (
{years.map((y) => (
{dateFr(y)}participation {pct(d.history[y][0]?.turnout)}
{d.history[y].slice(0, 6).map((h, i) => ())}
{h.elected && ●}{h.candidate}{h.party ? partyLabel(h.party) : h.label}{int(h.votes)}{pct(h.share)}
))}
)}
Fiche
); }